gRPC
Введение: Почему gRPC?
Представьте, что вы строите распределённую систему из микросервисов. Традиционный REST API с JSON работает, но вы замечаете проблемы: сериализация JSON медленная, нет строгих контрактов между сервисами, документация устаревает, а двунаправленная потоковая передача данных превращается в костыль.
gRPC решает эти проблемы элегантно. Это современный RPC-фреймворк от Google, который использует Protocol Buffers для сериализации и HTTP/2 для транспорта. Результат: высокая производительность, строгая типизация и встроенная поддержка стриминга.
Protocol Buffers — язык контрактов
Protocol Buffers (protobuf) — это язык описания данных, который компилируется в код на различных языках программирования.
Что такое файл .proto и из чего он строится
Файл с расширением .proto — это файл описания схемы данных в Protocol Buffers (Protobuf), разработанном Google. Это язык описания интерфейса (IDL), который определяет структуру данных (сообщения), перечисления, сервисы и т.д. Из такого файла компилятор protoc генерирует код на разных языках (C++, Java, Python, Go и др.) для сериализации/десериализации данных.
Файл .proto — это текстовый файл с синтаксисом, похожим на C/C++. Он состоит из нескольких основных элементов. Сейчас самая распространённая версия — proto3 (упрощённая по сравнению с proto2).
Основная структура файла .proto
Файл строится из следующих ключевых частей (в порядке, в котором они обычно появляются):
-
Объявление версии (syntax)
Первая непустая строка (не комментарий) — обязательно указывает версию.syntax = "proto3"; // или "proto2" для старой версии -
Пакет (package)
Определяет пространство имён, чтобы избежать конфликтов имён.package foo.bar; -
Импорты (import)
Подключает определения из других .proto-файлов.import "google/protobuf/timestamp.proto";
import public "other.proto"; // public — для реэкспорта -
Опции (option)
Метаданные для генерации кода (например, для Java или C++).option java_package = "com.example.myproto";
option java_outer_classname = "MyProto"; -
Определения сообщений (message)
Основная часть: описывают структуру данных, как класс или struct.
Внутри — поля, вложенные сообщения, перечисления и т.д.
Каждое поле: тип, имя, уникальный номер (tag).message Person {
string name = 1;
int32 id = 2;
string email = 3;
repeated string phones = 4; // массив
} -
Перечисления (enum)
Набор именованных констант.enum PhoneType {
PHONE_TYPE_UNSPECIFIED = 0; // всегда 0 для неизвестного
MOBILE = 1;
HOME = 2;
WORK = 3;
} -
Карты (map)
Словарь (ключ-значение).map<string, int32> scores = 5; -
Oneof
Группа полей, из которых только одно может быть установлено.oneof contact {
string email = 6;
string phone = 7;
} -
Сервисы (service)
Для gRPC: описывают RPC-методы (не всегда используется).service MyService {
rpc GetPerson(PersonRequest) returns (Person);
rpc StreamData(stream DataRequest) returns (stream DataResponse);
} -
Зарезервированные поля (reserved)
Чтобы в будущем не использовать определённые номера или имена.reserved 10, 15 to 20;
reserved "old_field";
Пример .proto файла для сервиса управления задачами
// task.proto
syntax = "proto3";
package taskpb;
option go_package = "github.com/yourname/taskservice/pb";
// Сообщение Task
message Task {
string id = 1;
string title = 2;
string description = 3;
bool completed = 4;
int64 created_at = 5;
}
// Запрос на создание задачи
message CreateTaskRequest {
string title = 1;
string description = 2;
}
message CreateTaskResponse {
Task task = 1;
}
// Запрос на получение задачи
message GetTaskRequest {
string id = 1;
}
message GetTaskResponse {
Task task = 1;
}
// Запрос на список задач
message ListTasksRequest {
int32 page_size = 1;
string page_token = 2;
}
message ListTasksResponse {
repeated Task tasks = 1;
string next_page_token = 2;
}
// Определение сервиса
service TaskService {
rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse);
rpc GetTask(GetTaskRequest) returns (GetTaskResponse);
rpc ListTasks(ListTasksRequest) returns (ListTasksResponse);
// Стриминг: сервер отправляет обновления в реальном времени
rpc WatchTasks(ListTasksRequest) returns (stream Task);
}
Ключевые моменты:
- Каждое поле имеет уникальный номер (это номера для бинарного формата)
repeatedозначает массивstreamозначает потоковую передачу данных
После написания .proto-файла запускаете protoc — он генерирует классы для работы с данными. Protobuf эффективен, компактен и поддерживает эволюцию схемы (добавление полей без ломания старого кода).
Установка компилятора Protocol Buffers
Компилятор protocol buffer, protoc, используется для компиляции файлов .proto, в которых содержатся определения сервисов и сообщений. Выберите один из методов ниже для установки protoc.
Установка предкомпилированных бинарных файлов (любая ОС)
Чтобы установить последнюю версию компилятора protocol из предкомпилированных бинарных файлов, следуйте этим инструкциям:
-
С сайта https://github.com/protocolbuffers/protobuf/releases вручную скачайте zip-файл, соответствующий вашей операционной системе и архитектуре компьютера (
protoc-<version>-<os>-<arch>.zip), или загрузите файл с помощью команд, подобных следующим:PB_REL="https://github.com/protocolbuffers/protobuf/releases"
curl -LO $PB_REL/download/v33.2/protoc-33.2-linux-x86_64.zip -
Распакуйте файл в
$HOME/.localили в каталог на ваш выбор. Например:unzip protoc-33.2-linux-x86_64.zip -d $HOME/.local -
Обновите переменную окружения PATH, чтобы включить путь к исполняемому файлу
protoc. Например:export PATH="$PATH:$HOME/.local/bin"
Установка с помощью менеджера пакетов
Предупреждение
После установки с помощью менеджера пакетов выполнитеprotoc --version, чтобы проверить версиюprotocи убедиться, что она достаточно новая. Версииprotoc, устанавливаемые некоторыми менеджерами пакетов, могут быть довольно устаревшими. Смотрите страницу Поддержка версий, чтобы сравнить результат проверки версии с номером минорной версии поддерживаемой версии языка(ов), которые вы используете.
Вы можете установить компилятор protocol, protoc, с помощью менеджера пакетов в Linux, macOS или Windows, используя следующие команды.
-
Linux, с использованием
aptилиapt-get, например:apt install -y protobuf-compiler
protoc --version # Убедитесь, что версия компилятора 3+ -
macOS, с использованием Homebrew:
brew install protobuf
protoc --version # Убедитесь, что версия компилятора 3+ -
Windows, с использованием Winget:
> winget install protobuf
> protoc --version # Убедитесь, что версия компилятора 3+
Другие варианты установки
Если вы хотите собрать компилятор protocol из исходного кода или получить доступ к старым версиям предкомпилированных бинарных файлов, смотрите страницу Скачивание Protocol Buffers.
# Go плагины для protoc
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
Компиляция proto-файла
Скомпилируйте proto-файл:
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
task.proto
Это создаст два файла: task.pb.go (структуры данных) и task_grpc.pb.go (интерфейсы сервиса).
Реализация gRPC сервера
Теперь напишем сервер, который реализует наш TaskService:
package main
import (
"context"
"fmt"
"log"
"net"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/yourname/taskservice/pb"
)
// TaskServer реализует интерфейс TaskServiceServer
type TaskServer struct {
pb.UnimplementedTaskServiceServer
mu sync.RWMutex
tasks map[string]*pb.Task
}
func NewTaskServer() *TaskServer {
return &TaskServer{
tasks: make(map[string]*pb.Task),
}
}
// CreateTask создаёт новую задачу
func (s *TaskServer) CreateTask(ctx context.Context, req *pb.CreateTaskRequest) (*pb.CreateTaskResponse, error) {
// Валидация
if req.Title == "" {
return nil, status.Error(codes.InvalidArgument, "title is required")
}
// Создаём задачу
task := &pb.Task{
Id: generateID(),
Title: req.Title,
Description: req.Description,
Completed: false,
CreatedAt: time.Now().Unix(),
}
s.mu.Lock()
s.tasks[task.Id] = task
s.mu.Unlock()
log.Printf("Created task: %s", task.Id)
return &pb.CreateTaskResponse{Task: task}, nil
}
// GetTask получает задачу по ID
func (s *TaskServer) GetTask(ctx context.Context, req *pb.GetTaskRequest) (*pb.GetTaskResponse, error) {
s.mu.RLock()
task, exists := s.tasks[req.Id]
s.mu.RUnlock()
if !exists {
return nil, status.Errorf(codes.NotFound, "task %s not found", req.Id)
}
return &pb.GetTaskResponse{Task: task}, nil
}
// ListTasks возвращает список всех задач
func (s *TaskServer) ListTasks(ctx context.Context, req *pb.ListTasksRequest) (*pb.ListTasksResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
tasks := make([]*pb.Task, 0, len(s.tasks))
for _, task := range s.tasks {
tasks = append(tasks, task)
}
return &pb.ListTasksResponse{Tasks: tasks}, nil
}
// WatchTasks отправляет обновления в реальном времени (server streaming)
func (s *TaskServer) WatchTasks(req *pb.ListTasksRequest, stream pb.TaskService_WatchTasksServer) error {
// Отправляем существующие задачи
s.mu.RLock()
for _, task := range s.tasks {
if err := stream.Send(task); err != nil {
s.mu.RUnlock()
return err
}
}
s.mu.RUnlock()
// Здесь в реальном приложении вы бы подписались на обновления
// Для примера просто держим соединение открытым
<-stream.Context().Done()
return stream.Context().Err()
}
func generateID() string {
return fmt.Sprintf("task_%d", time.Now().UnixNano())
}
func main() {
// Создаём TCP listener
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
// Создаём gRPC сервер
grpcServer := grpc.NewServer()
// Регистрируем наш сервис
taskServer := NewTaskServer()
pb.RegisterTaskServiceServer(grpcServer, taskServer)
log.Println("gRPC server listening on :50051")
// Запускаем сервер
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
Важные детали:
UnimplementedTaskServiceServerвстраивается для обратной совместимости- Используем
sync.RWMutexдля безопасного доступа к данным - gRPC ошибки используют специальные коды (
codes.InvalidArgument,codes.NotFound) - Контекст передаётся автоматически и поддерживает отмену операций
Создание gRPC клиента
package main
import (
"context"
"io"
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "github.com/yourname/taskservice/pb"
)
func main() {
// Подключаемся к серверу
conn, err := grpc.Dial("localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
// Создаём клиент
client := pb.NewTaskServiceClient(conn)
// Контекст с таймаутом
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Создаём задачу
createResp, err := client.CreateTask(ctx, &pb.CreateTaskRequest{
Title: "Изучить gRPC",
Description: "Пройти глубокий урок по Go + gRPC",
})
if err != nil {
log.Fatalf("CreateTask failed: %v", err)
}
log.Printf("Created task: %v", createResp.Task)
// Получаем задачу
getResp, err := client.GetTask(ctx, &pb.GetTaskRequest{
Id: createResp.Task.Id,
})
if err != nil {
log.Fatalf("GetTask failed: %v", err)
}
log.Printf("Retrieved task: %v", getResp.Task)
// Создаём ещё несколько задач
for i := 0; i < 3; i++ {
client.CreateTask(ctx, &pb.CreateTaskRequest{
Title: fmt.Sprintf("Task %d", i+1),
})
}
// Получаем список задач
listResp, err := client.ListTasks(ctx, &pb.ListTasksRequest{})
if err != nil {
log.Fatalf("ListTasks failed: %v", err)
}
log.Printf("Total tasks: %d", len(listResp.Tasks))
// Демонстрация стриминга
demonstrateStreaming(client)
}
func demonstrateStreaming(client pb.TaskServiceClient) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stream, err := client.WatchTasks(ctx, &pb.ListTasksRequest{})
if err != nil {
log.Fatalf("WatchTasks failed: %v", err)
}
log.Println("Watching tasks...")
for {
task, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Fatalf("Stream error: %v", err)
}
log.Printf("Received task: %s - %s", task.Id, task.Title)
}
}
Продвинутые концепции
Interceptors (Middleware)
Interceptors позволяют добавить сквозную логику (логирование, аутентификацию, метрики):
// Unary interceptor (для обычных RPC)
func loggingInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
start := time.Now()
log.Printf("Method: %s, Request: %v", info.FullMethod, req)
// Вызываем обработчик
resp, err := handler(ctx, req)
log.Printf("Method: %s, Duration: %v, Error: %v",
info.FullMethod, time.Since(start), err)
return resp, err
}
// Добавляем к серверу
grpcServer := grpc.NewServer(
grpc.UnaryInterceptor(loggingInterceptor),
)
Bidirectional Streaming
Двунаправленный стриминг позволяет клиенту и серверу обмениваться сообщениями одновременно:
service ChatService {
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
func (s *ChatServer) Chat(stream pb.ChatService_ChatServer) error {
for {
msg, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
// Обрабатываем сообщение и отправляем ответ
response := &pb.ChatMessage{
User: "bot",
Message: fmt.Sprintf("Echo: %s", msg.Message),
}
if err := stream.Send(response); err != nil {
return err
}
}
}
Metadata (заголовки)
Metadata используется для передачи дополнительной информации:
// Сервер: чтение metadata
func (s *Server) SomeMethod(ctx context.Context, req *pb.Request) (*pb.Response, error) {
md, ok := metadata.FromIncomingContext(ctx)
if ok {
token := md.Get("authorization")
log.Printf("Auth token: %v", token)
}
// ...
}
// Клиент: отправка metadata
md := metadata.Pairs(
"authorization", "Bearer token123",
"user-id", "user456",
)
ctx := metadata.NewOutgoingContext(context.Background(), md)
resp, err := client.SomeMethod(ctx, req)
Обработка ошибок с деталями
gRPC поддерживает богатые ошибки с дополнительными деталями:
import "google.golang.org/genproto/googleapis/rpc/errdetails"
func (s *Server) CreateTask(ctx context.Context, req *pb.CreateTaskRequest) (*pb.CreateTaskResponse, error) {
if req.Title == "" {
st := status.New(codes.InvalidArgument, "validation failed")
// Добавляем детали ошибки
br := &errdetails.BadRequest{}
br.FieldViolations = append(br.FieldViolations, &errdetails.BadRequest_FieldViolation{
Field: "title",
Description: "title cannot be empty",
})
st, _ = st.WithDetails(br)
return nil, st.Err()
}
// ...
}
Best Practices
Структура проекта:
taskservice/
├── proto/
│ └── task.proto
├── pb/ # сгенерированный код
│ ├── task.pb.go
│ └── task_grpc.pb.go
├── server/
│ └── main.go
├── client/
│ └── main.go
└── go.mod
Рекомендации:
- Всегда используйте контексты с таймаутами
- Обрабатывайте отмену операций через
ctx.Done() - Используйте connection pooling для клиентов в production
- Добавьте health checks:
grpc.health.v1.Health - Включите reflection для debugging:
reflection.Register(grpcServer) - Используйте TLS в production с
credentials.NewServerTLSFromFile() - Логируйте через interceptors, а не в каждом методе
- Версионируйте proto-файлы (например,
taskpb.v1,taskpb.v2)
Заключение
gRPC + Go — это мощная комбинация для построения высокопроизводительных распределённых систем. Protocol Buffers обеспечивают строгие контракты, HTTP/2 даёт эффективный транспорт, а встроенная поддержка стриминга открывает возможности для real-time приложений.
Начните с простых unary RPC, затем экспериментируйте со стримингом и interceptors. Главное — помните, что gRPC отлично подходит для взаимодействия между сервисами, но для браузерных клиентов REST API или gRPC-Web могут быть более подходящими решениями.